Skip to content

Adopted-entity curation: user-asserted metadata from a new Adopted tab - #266

Merged
cayossarian merged 27 commits into
mainfrom
feat/adopted-curation
Sep 1, 2026
Merged

Adopted-entity curation: user-asserted metadata from a new Adopted tab#266
cayossarian merged 27 commits into
mainfrom
feat/adopted-curation

Conversation

@cayossarian

Copy link
Copy Markdown
Member

What

Adopted entities (adopted devices and vendor readings on modelled devices) can now be curated by the user: a device class, a statistics class, and prominence are asserted from the dashboard's new admin-only Adopted tab, stored in a per-entry .storage overlay keyed by scope-prefixed wire addresses, and applied when entities are constructed — so after the automatic reload a curated entity is born with its metadata rather than patched afterwards.

How

  • curation.py owns the record type, validation (refuses at save, re-runs at construction — stale fields drop with one warning and never block setup), the store, and the description helpers. It is the single audited module that may spell state_class; the AST guards on adoption.py and extension.py stay absolute, and extension.py gains the guard it was missing.
  • Two admin websocket commands: span_panel/adopted/list (rows grouped by device, with server-computed allowed classes and stale-field marks) and span_panel/adopted/curate (validate → save → schedule reload — zero registry writes, asserted by object identity in tests).
  • Enable, name, icon, display unit and precision are Home Assistant's own registry writes, issued by the frontend against Core's websocket; the integration never touches them. disabled_by is omitted whenever the enable control is untouched, so an integration-disabled entity is never silently re-attributed to the user.
  • Identity is untouched everywhere: same unique_ids, same entity_ids, same platforms; the overlay reshapes what an entity declares, never what it is. Curation is forgotten with the entry.
  • Diagnostics gain an adopted_curation block (keys and enum values only). Docs updated: README, developer.md, websocket-api.md (including the pre-existing not_panel_device drift), frontend.md, CHANGELOG.
  • Ships the built span-card Adopted tab (frontend dist synced from SpanPanel/span-card feat/adopted-curation) and chore(release): 2.1.1b3.

Testing

Full suite on the merged tree: 1765 passed, 1 skipped (89 new tests across curation, adoption, extension, websocket, diagnostics). mypy/ruff clean; typing and entity-id audits pass.

Read the stored overlay into runtime data before the platforms are forwarded,
so every adopted entity is born with its curated metadata rather than having it
applied after its first state is written. The field is required rather than
defaulted: a setup path that forgets the load has to fail loudly, because an
empty overlay is indistinguishable from a user who has curated nothing.

Removing the entry forgets the store, as it already forgets the announcement
record. The keys are wire addresses rather than registry ids, so a store left
behind is one the next entry for the same panel would load and apply.
developer.md gains a Curation section covering the .storage overlay and its
scope-prefixed keys, validation refusing at save and dropping at construction,
the description helpers that keep both AST guards absolute, the two websocket
commands' zero-registry-writes boundary, and why the reload is the mechanism
rather than a courtesy. The revisable-vs-not table's state_class row moves from
"never set at all" to user-curated only, and the diagnostics section gains the
adopted_curation block's withholding rule.

README.md documents the Adopted tab: what the integration sets versus what is
Home Assistant's own, that saving reloads, the total_increasing and
statistics-class-removal consequences, and that identity never changes.
…e classes match it

A unit-less reading was published as text however the wire declared it: the
unit stood in for "this is numeric", so a bare count arrived as the string
"42". That was harmless while an uncurated row asserted nothing about itself,
and stopped being harmless once its owner could put a `measurement` on exactly
that row -- the recorder would be handed a string under a numeric state class.
`declares_a_number` now answers the question once, in `util`, for the union of
a declared unit and a numeric `$datatype`, so nothing that parses today stops
parsing and a bare count parses too.

The device-class offer was gated on the declared unit alone, which let a text
row be offered `power_factor`, `aqi` and `monetary` -- classes constraining no
unit, and so passing vacuously -- each of which reads unknown for the life of
the install. Core's own `NON_NUMERIC_DEVICE_CLASSES` partitions the vocabulary
and `declares_a_number` says which side a row falls on, so the editor offers
one half or the other and the validator refuses the crossing with
`incompatible_device_class`. Sharing the predicate with the parse is what keeps
a row read as a float from being offered nothing but `enum`.
The Adopted tab no longer offers an icon: it wrote through the same registry
command Core's own entity settings dialog issues, so it was a second place to
set one thing. The field lists in the README and frontend.md drop it and say
where the icon and the area are set instead.

developer.md's validation section gains the datatype half of the device-class
gate, and the reason `declares_a_number` is one predicate rather than two: the
same answer decides whether a reading is parsed as a number and which half of
Core's device-class vocabulary the row is offered.
The condensed Adopted sections left an orphaned </details>, a bullet
with scrambled markup, two sentences split mid-phrase, and two dead
cross-references (#adopted-vendor-readings, and #bess--grid-management
which bess-grid-management.md links to). Restore the BESS heading level
so its four subsections are parented again, and give "the confirmation"
and the reading-vs-control distinction the antecedents their cut
paragraphs used to supply.

Normalize documentation prose to US spelling. The identifier references
`is_modelled` and the normalising test name stay en-GB so they still
name the Python symbols they refer to.
…eases

The lockfile still recorded homeassistant 2026.8.0 and span-panel-api 3.3.0
while pyproject pins 2026.8.3 and 3.4.1, so uv lock --check failed and every
bare uv run re-resolved and left the file dirty.
The span-panel-api path in [tool.uv.sources] is relative to the repository
root, so it arrives from the primary checkout and from a worktree beside it,
and misses from one nested inside the checkout at .claude/worktrees/<name>,
which sits two levels deeper. uv run then cannot build an environment at all,
so pylint, mypy, vulture, both radon hooks and the test suite fail before they
start -- and the first commit from a new worktree is where you find out.

setup-hooks.sh now bridges the gap with a symlink, both ends derived from git
rather than written down, and asks git for the pre-commit hook path rather than
assuming .git/hooks, which a worktree's .git file is not.

check-library-path.py claimed the relative path resolves to the same directory
from every worktree. It does not, and the claim is why this reads as a broken
pin rather than a path-depth problem.
@cayossarian
cayossarian merged commit ca13e81 into main Sep 1, 2026
7 checks passed
@cayossarian
cayossarian deleted the feat/adopted-curation branch September 1, 2026 04:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant